iT邦幫忙

2024 iThome 鐵人賽

DAY 11
1

為什麼需要模組化結構?

這個話題我跟 AI 討論很久,就老實跟大家說吧,文章順序考慮很久,砍了又改、改了又砍。那麼,還是先把架構講清楚吧!

首先,我不想這變成一篇充滿解釋性內容的學術文章。就個人經驗來說,當 Canvas 繪圖需要的程式碼超過 400 行,維護起來就不容易。雖然可以用 Class 和函式建構式等技巧來增強結構性,並且安排先後順序,讓相關性高的程式碼歸類在一起,但本質上還是存在一個問題:架構只存在你腦海裡,過一段時間回來看就忘記了。

在設計模組化結構並實踐的過程中,我認為最重要的一點是架構應該一目了然。就像我們在資料夾中搜尋文件一般,你也能輕鬆的在物件中找到你要修改的功能。然而,怎樣才是合理的設計,符合每個場景的最佳答案,我認為,這是很難回答的,只能分享我的看法!

這裡應該要有一張 drawio 流程圖,畫到一半畫不完,如果你想看,請在留言區敲碗 ><

繪圖很簡單不是嗎?

你腦海中的基本系統的運作流程可能如下:

  • 演算:進行計算和邏輯處理。
  • 繪圖:根據計算結果在畫布上進行渲染。
  • 封裝動畫:將這些步驟封裝成一個動畫,使得整個系統能夠不斷更新和渲染。

但別忘了,還要進行狀態管理,處理 canvas 元素初始化、事件監聽、記憶體回收,如果這些添加進去,全部放在一起,檔案的複雜度就會不斷上升。

所以,我的作法是假裝存在一個後端,透過中間人和前端進行交互,讓後端專心演進行演算。

角色介紹:中間人與(後端)演算法

在這個物件結構中,我們可以將其視為一位中間人,這位中間人擔負著重要的溝通角色,連接了前端的視覺呈現與底層的運算邏輯。這位中間人負責接受 canvas 元素,並進行相關的初始化設定,為粒子系統建立一個良好的基礎。

中間人的工作

  • 接收 Canvas 元素:中間人首先接受來自前端的 canvas 元素,這是渲染所有視覺內容的舞台。
  • 初始化與設定:它會設定必要的屬性,比如 canvas 的上下文,並為底層演算法準備運行環境。
  • 調用演算法:當一切準備就緒後,中間人會告訴演算法,該開始渲染的對象是誰,並管理更新和渲染的過程。
import createAlgorithm from './algorithm'
const createMyCanvas = function(){
	this.setCanvas = (canvas) => {
		this.ctx = canvas.getContext("2d");
		this.algorithm = new createAlgorithm();
	}
	this.cleanup = () => {
		this.ctx = null;
		this.algorithm = null;
	}
	this.render = () => {
		this.algorithm.render(this.ctx);
	}
	this.update = () => {
		this.algorithm.update(this.ctx);
	}
	return this;
}
const myCanvas = new createMyCanvas();
export default myCanvas;

setCanvas 負責初始化;cleanup 負責釋放記憶體空間(如元件卸載時)。結合上篇所說,我們就可以把 update 跟 render 方法單獨拿出來註冊動畫

演算法的角色

而在這樣的背景下,演算法則扮演著一位專注的藝術家,對於它的工作過程非常純粹。它不需要關心畫布的細節,只需專心渲染由中間人所指定的對象。當中間人發出指令時,演算法會立刻反應,將粒子系統中的每一個粒子都渲染到畫布上。

前後分離

因此,在結構上我們結合物件導向和功能性編程的優勢,不僅將狀態管理和底層細節分離,也封裝底層細節將功能抽象化,提供接口讓前端可以輕鬆交互。以下還是舉實際的例子吧!分別是後續會談到的主題。

例子一:掠食者獵物方程 + Web Worker

在這個狀態管理的例子中,我們在底層除了基本的演算法外,還分配了任務給 Web Worker,並提供它 Offscreen Canvas 進行離屏渲染。此外,這兩者都會根據畫布寬高,自適應調整繪圖,因此,需要將其作為參數傳遞,並且在螢幕寬高發生改變時額外傳遞給 webWorker。最後,它需要用 terminate 方法來清除,我們可以將其包裝在清理函式內。

const createLokaVolterra = function(){
	this.setCanvas = (canvas, bitmap) => {
		this.algorithm = new lokaVolterraAlgorithm();
		this.myWorker = new MyWorker();
		this.algorithm.reset(canvas.width, canvas.height);
		this.canvas = canvas;
		this.ctx = canvas.getContext("2d");
		this.bitmap = bitmap;
		this.offscreen = bitmap.transferControlToOffscreen();
		this.myWorker.postMessage({
			"name": "transferControlToOffscreen",
			"canvas": this.offscreen,
		}, [this.offscreen]);
	}
	this.cleanup = () => {
		this.algorithm = null;
		this.canvas = null;
		this.ctx = null;
		this.bitmap = null;
		this.offscreen = null;
		this.myWorker.terminate();
		this.myWorker = null;
	}
	this.resize = () => {
		this.myWorker.postMessage({
			"name": "setOffscreen",
			"w": this.canvas.width,
			"h": this.canvas.height
		});
	}
	this.pauseWorker = (isPause) => {
		this.myWorker.postMessage({"name": isPause ? "requestAnimation" : "cancelAnimation"});
	}
	this.render = () => {
		this.algorithm.render(this.ctx);
	}
	this.update = () => {
		this.algorithm.update(this.ctx, this.canvas.width, this.canvas.height);
	}
	return this;
}

這些方法就可以提供給前端使用,拿 React 為例:

const canvas = useRef(null);
const bitmap = useRef(null);
useEffect(()=>{
	window.addEventListener("resize", lokaVolterra.resize, false);
    lokaVolterra.setCanvas(canvas.current, bitmap.current);
	return () => {
		window.removeEventListener("resize", lokaVolterra.resize);
        lokaVolterra.cleanup();
	}
}, []);

https://ithelp.ithome.com.tw/upload/images/20240925/20135197DPLLZDLMmh.png

例子二:從排序演算法瞥見粒子系統

在這個例子中,我們用了三層物件結構,中間人對接粒子系統,粒子系統對接排序演算法,這樣的結構應該不難理解:因為演算法只是粒子系統的一部分;而粒子系統又是整個網頁的一部分。

這裡,中間人同樣提供狀態管理的接口,讓前端可以送出指令,要求執行特定的排序演算法,或是進行取消、逐步執行、設定動畫路徑等指令。

const createPhysic = function(){
    const frame = new Averager(60);
	this.setCanvas = (canvas, pElement) => {
        this.system = new ParticleSystem(canvas.width/2, canvas.height/2);
        this.system.sort.setLog(pElement);
        this.ctx = canvas.getContext("2d");
        this.ctx.lineCap = 'butt';
        this.ctx.textAlign = 'center';
	}
    this.cleanup = () => {
        this.system = null;
        this.ctx = null;
    }
    this.update = () => {this.system.update();}
    this.render = () => {this.system.render(this.ctx);}
    this.start = (ID, path) => {
        this.setPath(path);
        this.system.sort.start(ID, this.system.columns);
    }
    this.cancel = () => {
        this.system.sort.isSorting = false;
    }
    this.stepByStep = () => {
        this.system.sort.isSorting = true;
        this.system.sort.isStoping = true;
    }
    this.setPath = (path) => {
        Object.assign(PathConfig, path);
    }
	return this;
}

PathConfig 是在 A2 設計的動畫物件繼承類別,用來設定動畫路徑

https://ithelp.ithome.com.tw/upload/images/20240925/20135197nkyrV7nmUv.png

例子三:Three.js 手作3D長條圖

在這裡,想像的後端有兩個主要部分:

  1. analyser:由於 Web Audio API 的限制,必須在使用者同意或播放音樂後才能初始化,所以需要一個接口讓前端可以進行狀態管理並調用。
  2. BufferFactory:在演算法方面,這個類別專注於將數據繪製成 3D 圖形。

透過這個中間人,我們可以良好的管理這兩者的交互。

const createMusicAnalyser = function(){
    this.firstTime = true;
    this.getAnalyser = (e) => {
        const audio = e.target;
        if(this.firstTime) this.analyser = createAnalyser(audio);
        this.firstTime = false;
    }
    this.setCanvas = (canvas) => {
        this.canvas = canvas;
        this.scene = new THREE.Scene();
        this.renderer = new THREE.WebGLRenderer({"alpha": true, "canvas": canvas});
        this.renderer.setClearColor(0x000000, 0);
        // 設置鏡頭
        this.camera = new THREE.PerspectiveCamera( 75, canvas.width / canvas.height, 0.1, 1000 );
        const radius = 300;
        this.camera.position.set(radius/4, radius/3, radius/3);
        // 添加控制和座標軸
        this.controls = new OrbitControls(this.camera, this.renderer.domElement);
        this.controls.target.set(radius/4, 0, -radius/3);
        this.axis = new THREE.AxesHelper(300);
        this.scene.add(this.axis);
        // 添加群組到場景
        this.buff = new BufferFactory();
        this.ball = makeBall(radius, 60, 30, radius/500);
        this.group1 = new THREE.Group();
        this.group1.add(this.buff.mesh, this.ball);
        this.scene.add(this.group1);
    }
    this.cleanup = () => {
        this.firstTime = true;

        // 移除場景中的對象
        if (this.scene) {
			// 釋放幾何體和材質
            // 移除場景中的所有子對象
        }

        // 釋放渲染器
        if (this.renderer) {
            this.renderer.dispose();
        }
        
        this.buff = null;
        this.canvas = null;
        this.scene = null;
        this.camera = null;
        this.renderer = null;
        this.axis = null;
        this.analyser = null;
    }
    this.resize = () => {
        const [w, h] = [this.canvas.width, this.canvas.height];
        this.renderer.setSize(w, h);
        this.camera.aspect = w/h;
        this.camera.updateProjectionMatrix();
    }
    this.update = () => {
        if(this.analyser){
            const bufferLength = this.analyser.frequencyBinCount;
            const dataArray = new Uint8Array(bufferLength);
            this.analyser.getByteFrequencyData(dataArray);
            const data = [...dataArray].splice(0,128);
            this.buff.transformData(data);
        }
        this.controls.update();
        this.buff.update();
    }
    this.render = () =>{
        this.renderer.render( this.scene, this.camera );
    }
	return this;
}

https://ithelp.ithome.com.tw/upload/images/20240925/2013519768n2TZyBze.png

結論

這篇文章強調了透過中間人模式建立模組化結構的必要性,以及如何通過清晰的界面來管理狀態和簡化交互。透過分離關注點,我們不僅能提高程式碼的可維護性,也能讓演算法與前端操作保持良好的互動性。

不過,無論架構如何設計,最終都還是需要回歸到「維護性」這個核心目標。如果只是一味追求可擴展性,卻忘記了原始目的,則有可能讓整個系統變得不堪重負。希望這篇文章能拋磚引玉,跟大家想法互相碰撞,激發良好的設計模式!


上一篇
B2 玩轉 IntersectionObserver:揭秘動畫管理員的設計與實作細節!
下一篇
B4 在旋轉的沙漏中:掠食者與獵物的動態美學
系列文
讓演算法起舞:前端特效應用的探索之旅35
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言